Skip to content

feat: implement TypeScript Connect API client#3673

Open
zackverham wants to merge 16 commits intomainfrom
ts-connect-client
Open

feat: implement TypeScript Connect API client#3673
zackverham wants to merge 16 commits intomainfrom
ts-connect-client

Conversation

@zackverham
Copy link
Collaborator

@zackverham zackverham commented Mar 9, 2026

Intent

Implement a TypeScript Connect API client package (@posit-dev/connect-api) and validate it against contract tests alongside the existing Go backend.

Type of Change

  • New Feature
  • Refactoring

Approach

Created a standalone packages/connect-api/ package with a ConnectAPI class that wraps the Posit Connect REST API. All 15 API methods are implemented:

  • Auth & User: testAuthentication, getCurrentUser
  • Content CRUD: contentDetails, createDeployment, updateDeployment
  • Environment: getEnvVars, setEnvVars
  • Bundles: uploadBundle, latestBundleId, downloadBundle
  • Deployment: deployBundle, waitForTask, validateDeployment
  • Server Info: getIntegrations, getSettings

Key design decisions:

  • axios for HTTP — uses axios.create() with a pre-configured baseURL and default Authorization header. Each method calls this.client.request<T>() directly with response generics for strong typing.
  • Default axios error handling — non-2xx responses throw AxiosError automatically. No custom error classes. Two methods use per-request validateStatus overrides: testAuthentication (inspects error response bodies for messages) and validateDeployment (accepts non-5xx status codes).
  • Raw API responses — every method returns the exact JSON the Connect API sends back (full DTOs), rather than constructing new objects or extracting single fields. The contract test adapter handles any field extraction needed.
  • Branded ID types (ContentID, BundleID, TaskID) for type safety on parameters

User Impact

None — this is preparatory code.

Automated Tests

  • 43 unit tests in packages/connect-api/ (vitest) covering all methods, error paths, and URL construction
  • All 68 contract tests pass with both backends (npx vitest run and API_BACKEND=typescript npx vitest run)
  • CI runs contract tests against both Go and TypeScript backends

🤖 Generated with Claude Code

zackverham and others added 4 commits March 9, 2026 15:35
Replace the stub TypeScriptDirectClient with a full implementation that
makes HTTP requests directly to the Connect server, mirroring the Go
client's behavior. All 15 API methods are implemented and pass the
same 68 contract tests that validate the Go client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move the TypeScript Connect API client from the contract test file into
a standalone @posit-dev/connect-client package with proper types, error
classes, and barrel exports. The contract test adapter becomes a thin
wrapper that imports from the new package. All 68 contract tests pass
with both Go and TypeScript backends.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a matrix strategy to the contract-tests job so it runs once with
API_BACKEND=go (existing behavior) and once with API_BACKEND=typescript
(new ConnectClient from @posit-dev/connect-client). The TypeScript
backend skips Go setup since it doesn't need the harness binary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +12 to +14
"dependencies": {
"@posit-dev/connect-client": "file:../../packages/connect-client"
},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we continue down this path (which I think we should) we should consider doing a few things:

I think its a good idea to have separate packages. It makes the way we import and deal with some of the code around the homeView much easier to deal with. Right now it imports types from extension/vscode by importing with ../../.. which is not great.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dotNomad would you say creating a shared configuration package should be part of this PR, or part of follow-up work?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up for sure. Just mentioning it here to spread awareness

// HTTP helpers
// ---------------------------------------------------------------------------

private async request(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to keep using axios despite some of the problems we've had so we don't have to write our own error handling logic, or change how we do it in a lot of spots. That would simplify what we have here quite a bit.

Comment on lines +145 to +151
return {
id: dto.guid,
username: dto.username,
first_name: dto.first_name,
last_name: dto.last_name,
email: dto.email,
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a preference in design, but in my opinion it has a lot of benefits:

I would prefer to return the exact thing the API call responds with, including all data, and not creating new objects.

This will keep this code extremely single use and the only thing we will need to adjust overtime are the types. We won't have to adjust the functions since they just pass the response right through. We can always extract the data from the response.

It is much more flexible.

You can read more in our README.md for the Go API client we wrote: https://github.com/posit-dev/publisher/blob/183abd7d744fb8f6d8bcffc323717547521eca57/extensions/vscode/src/api/README.md

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this is mapping a UserDTO to a User, but I think we should avoid this. It would make the initial migration easier, but once it is complete it will be much more difficult to understand why the attributes are different.

Down to discuss temporary trade-offs.

Comment on lines +7 to +11
export type ContentID = string & { readonly __brand: "ContentID" };
export type BundleID = string & { readonly __brand: "BundleID" };
export type TaskID = string & { readonly __brand: "TaskID" };
export type UserID = string & { readonly __brand: "UserID" };
export type GUID = string & { readonly __brand: "GUID" };
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Branded Types are a nice addition to avoid ID overlap 👍

@zackverham zackverham changed the title feat: implement TypeScript Connect API client for contract tests feat: implement TypeScript Connect API client Mar 10, 2026
zackverham and others added 4 commits March 10, 2026 08:51
Rename the package from @posit-dev/connect-client to @posit-dev/connect-api
and add comprehensive unit tests for the ConnectClient class and error types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Also renames ConnectClientOptions to ConnectAPIOptions and
ConnectClientError to ConnectAPIError for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace native fetch with axios for HTTP requests in the ConnectAPI
class. Uses axios.create() with baseURL, default Authorization header,
and validateStatus: () => true to preserve existing error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stop constructing new objects in client methods — return the exact
response data from the Connect API instead. Removes the User type
(a client-side invention) and updates the contract test adapter to
extract fields from full DTOs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
};
}

private async dispatch(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

once we get rid of the go API client, we can clean up the shape of this stub, or just use the ConnectAPI client directly in test.

Right now the test shape is coupled to the go client's shape so we can have an apples-to-apples comparison in the contract tests, but we won't always need to have the apples-to-apples comparison we're running here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a clean-up comment for more inline-context?

});

// ---------------------------------------------------------------------------
// getSettings
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could be convinced that the right approach will end up being splitting these out into atomic calls - maybe something we can feel out as we go along.

* Validates credentials and checks user state (locked, confirmed, role).
* Returns the full UserDTO on success; throws AuthenticationError otherwise.
*/
async testAuthentication(): Promise<UserDTO> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not strictly a light wrapper around an API call, but this does seem pretty useful to have on the API client.


/**
* Fetches composite server settings from 7 separate endpoints,
* mirroring the Go client's GetSettings behavior.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging again - not sure if we want to mimic this or not

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, but I'm fine starting with it to make migrations easier. Eventually I think it would be better to split this up.

We definitely should not do an await cascade like this though. We should make all of these calls in parallel with Promise.all() or something similar.

Right now this will wait for each individual API call to complete before making the next one severely slowing down this fetch.

// Integration type
// ---------------------------------------------------------------------------

export interface Integration {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dotNomad is there any convention around calling something vs DTO?

Copy link
Collaborator

@dotNomad dotNomad Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure what you mean. Are you asking about a naming convention for API response objects?

Usually I don't distinguish types in that way, and just rely on plain names (no leading I for interfaces, or DTO signifier)

@zackverham zackverham marked this pull request as ready for review March 10, 2026 18:19
@zackverham zackverham requested a review from a team as a code owner March 10, 2026 18:19
Copy link
Collaborator

@dotNomad dotNomad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a few concerns about how the client is setup.

@@ -0,0 +1,19 @@
{
"name": "@posit-dev/connect-api",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be a follow-up. We probably want to add this package to the dependabot groups we have to make keeping it up-to-date easier.

};
}

private async dispatch(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a clean-up comment for more inline-context?

headers: {
Authorization: `Key ${options.apiKey}`,
},
validateStatus: () => true,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I understanding this correctly that this will make every HTTP code pass? This is counter to how I'd expect to use Axios - I would expect non-200s to throw

https://axios-http.com/docs/handling_errors

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that this would let the caller interpret the HTTP code - which is how I'd expect to handle it in go, but if its better to throw I'm happy to do that.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the normal axios behavior to throw errors for non-200 error codes. If I was using an axios client I would definitely be surprised by that behavior.

Comment on lines +57 to +86
private async request(
method: string,
path: string,
options?: {
body?: unknown;
contentType?: string;
rawBody?: Uint8Array;
responseType?: "arraybuffer";
},
): Promise<AxiosResponse> {
const headers: Record<string, string> = {};

let data: unknown;
if (options?.rawBody) {
headers["Content-Type"] =
options.contentType ?? "application/octet-stream";
data = options.rawBody;
} else if (options?.body !== undefined) {
headers["Content-Type"] = options.contentType ?? "application/json";
data = options.body;
}

return this.client.request({
method,
url: path,
headers,
data,
responseType: options?.responseType,
});
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit confused by this setup. Axios already has a client.request why wouldn't we use that by default?

I'm not seeing rawBody be used for example. Perhaps it would be clearer if that was setup in the individual API calls rather than in an abstract helper.

This also means as we used more axios features we'd have to expand this each time.

Comment on lines +88 to +102
private async requestJson<T>(
method: string,
path: string,
options?: { body?: unknown },
): Promise<T> {
const resp = await this.request(method, path, options);
if (resp.status < 200 || resp.status >= 300) {
const body =
typeof resp.data === "string"
? resp.data
: JSON.stringify(resp.data ?? "");
throw new ConnectRequestError(resp.status, resp.statusText, body);
}
return resp.data as T;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar note here about requestJson

I'm not sure what this is getting us.

Comment on lines +116 to +117
const errorBody = (resp.data ?? {}) as Record<string, unknown>;
const msg = (errorBody.error as string) ?? `HTTP ${resp.status}`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are needing to use as here a lot because we aren't typing the return from request. We ideally should be using an axios client get call and use the generic to type this for us to remove a lot of this as type assertions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah! I see - yeah that makes sense to me. Making the swap to use generics now.

Comment on lines +175 to +180
if (resp.status < 200 || resp.status >= 300) {
const respBody =
typeof resp.data === "string"
? resp.data
: JSON.stringify(resp.data ?? "");
throw new ConnectRequestError(resp.status, resp.statusText, respBody);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we doing this instead of letting the request throw default axios behavior bubble up?

: JSON.stringify(resp.data ?? "");
throw new ConnectRequestError(resp.status, resp.statusText, body);
}
return resp.data as BundleDTO;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should avoid returning the data and instead type the request with a generic so we can get the response returned that has data inside of it.

): Promise<TaskDTO> {
let firstLine = 0;

for (;;) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the same as a while true?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they're semantically equivalent, yeah. Changing it to while (true) if that improves readability.

Comment on lines +308 to +311
const resp = await this.request("GET", `/content/${contentId}/`);
if (resp.status >= 500) {
throw new DeploymentValidationError(contentId, resp.status);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this why we don't use the default axios throw logic? Can we make that a one off for this API call so we don't have to have so many error catching blocks?

zackverham and others added 8 commits March 10, 2026 16:23
Let axios throw AxiosError on non-2xx responses instead of manually
checking status codes and throwing custom error types. Two methods
use per-request validateStatus overrides: testAuthentication inspects
error response bodies, and validateDeployment accepts non-5xx codes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each method now calls this.client.request() directly with its own
config, eliminating the request() and requestJson() indirection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cast through unknown on the error path so the request can use
<UserDTO> generic, giving strong typing on the success path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The lockfile was missing resolved entries for platform-specific
optional dependencies like @rollup/rollup-linux-x64-gnu, causing
CI failures on Linux runners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The contract tests use a file: link to @posit-dev/connect-api, so
TypeScript needs axios resolvable when compiling through the linked
package source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The contract tests use a file: link to packages/connect-api, but CI
only ran npm ci in the contract tests directory. TypeScript needs
axios resolvable from the linked package source, so we must install
packages/connect-api deps first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Collaborator

@dotNomad dotNomad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm leaving comments to go off and do another review, but I'll do some work in the background on this and push up changes based on my comments for you to take a look at tomorrow morning @zackverham

Comment on lines +49 to +77
async testAuthentication(): Promise<UserDTO> {
const { data, status } = await this.client.request<UserDTO>({
method: "GET",
url: "/__api__/v1/user",
validateStatus: () => true,
});

if (status < 200 || status >= 300) {
const errorBody = ((data as unknown) ?? {}) as Record<string, unknown>;
const msg = (errorBody.error as string) ?? `HTTP ${status}`;
throw new Error(msg);
}

if (data.locked) {
throw new Error(`user account ${data.username} is locked`);
}

if (!data.confirmed) {
throw new Error(`user account ${data.username} is not confirmed`);
}

if (data.user_role !== "publisher" && data.user_role !== "administrator") {
throw new Error(
`user account ${data.username} with role '${data.user_role}' does not have permission to publish content`,
);
}

return data;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The amount of TypeScript type coercion happening here makes me a bit worried. I think we can simplify a bit using axios throw behavior, but catching it here as opposed to letting it throw up like the other methods to give us those custom Errors.

async testAuthentication(): Promise<UserDTO> {
    let data: UserDTO;
    try {
      ({ data } = await this.client.get<UserDTO>("/__api__/v1/user"));
    } catch (err) {
      if (axios.isAxiosError(err)) {
        const msg = (err.response?.data as Record<string, unknown>)?.error;
        throw new Error(typeof msg === "string" ? msg : `HTTP ${err.response?.status}`);
      }
      throw err;
    }
    // custom erroring

We can do something like this which is less fragile and custom.

Comment on lines +88 to +95
/** Fetches details for a content item by ID. */
async contentDetails(contentId: ContentID): Promise<ContentDetailsDTO> {
const { data } = await this.client.request<ContentDetailsDTO>({
method: "GET",
url: `/__api__/v1/content/${contentId}`,
});
return data;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same as latestBundleId. Perhaps we simplify and remove latestBundleId


/** Composite settings from all 7 server endpoints. */
export interface AllSettings {
General: ServerSettings;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a typo. We are renaming the __api__/server_settings return, but perhaps we use a key like serverSettings. Caps is a bit strange.

method: "GET",
url: "/__api__/v1/user",
});
const { data: General } = await this.client.request<ServerSettings>({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note here about the odd casing.


/**
* Fetches composite server settings from 7 separate endpoints,
* mirroring the Go client's GetSettings behavior.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, but I'm fine starting with it to make migrations easier. Eventually I think it would be better to split this up.

We definitely should not do an await cascade like this though. We should make all of these calls in parallel with Promise.all() or something similar.

Right now this will wait for each individual API call to complete before making the next one severely slowing down this fetch.

Comment on lines +81 to +82
const { data } = await this.client.request<UserDTO>({
method: "GET",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can simplify this to client.get which is a bit more concise and easier to read IMO

// ---------------------------------------------------------------------------

describe("contentDetails", () => {
const contentId = "content-guid-abc" as ContentID;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can make a factory function to assist with reducing the number of type coercions we use in here. Very minor nit though.

while (true) {
const { data: task } = await this.client.request<TaskDTO>({
method: "GET",
url: `/__api__/v1/tasks/${taskId}?first=${firstLine}`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use axios params to fill this out.

axios handles encoding and is more close to how axios wants to be used.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants